fix(0.6.0): establish explicit runtime lifecycle ownership - #226
Conversation
Epic 1.3 (Runtime Lifecycle Ownership): every runtime-created engine has exactly one reachable lifecycle owner; closing it deterministically prevents further work and terminates TramAI-owned work. - Tramai now owns ONE lazily-created TramaiRuntime (one engine) shared by all create()/runtime() calls; lifecycle state lives in class-body fields so the published JVM constructor descriptor is unchanged. Tramai is now AutoCloseable; close() is idempotent and synchronized; after close, create()/runtime() fail fast with a fixed IllegalStateException. - Engine proxies fail after close BEFORE provider execution (closed flag checked at the invocation handler seam). - TramaiEngine.close() cancels once and joins (except from its own coroutines, avoiding self-close deadlock), and explicitly cancels tracked suspend-invocation jobs: suspend bridges launch as children of the CALLER job (preserving parent-cancellation propagation) while the engine tracks them so close() owns in-flight work. - SovereignTramai propagates the same ownership: create()/runtime() share the delegate's owned runtime; SovereignTramai is AutoCloseable closing the delegate. - Spring: the Tramai bean uses destroyMethod = close so context destruction closes the shared runtime; multiple @aiservice beans share one engine. - Resource ownership rule documented: TramAI closes only resources it creates; externally supplied providers/stores/clients/observers remain caller-owned. - Tests: shared lifecycle, single engine under concurrency, no resurrection after close, idempotent close, proxy-after-close fails before provider, in-flight suspend terminates on close, self-close no deadlock, Spring destruction + shared-engine, sovereign equivalence, external deps not closed. api dumps updated additively (AutoCloseable only).
There was a problem hiding this comment.
Pull request overview
Establishes explicit runtime lifecycle ownership so a single Tramai instance deterministically owns (and can close) exactly one lazily-created runtime/engine, with corresponding changes in engine shutdown semantics, sovereign delegation, Spring bean lifecycle wiring, and new tests validating the ownership/closure invariants.
Changes:
- Make
TramaiandSovereignTramaiAutoCloseable, withTramaiowning a single sharedTramaiRuntimeacrosscreate()/runtime()and failing fast after close. - Strengthen
TramaiEngineshutdown behavior with a closed flag, invocation-time closed checks, and tracking/cancellation of in-flight suspend invocations on close. - Wire Spring to close the shared
Tramaibean at context shutdown and add coverage across standalone/engine/sovereign/spring.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiTest.kt | Adds lifecycle ownership and close-behavior tests for standalone Tramai. |
| tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt | Makes Tramai AutoCloseable and enforces a single owned runtime with synchronized lifecycle state. |
| tramai-standalone/api/tramai-standalone.api | Public API update reflecting AutoCloseable + close(). |
| tramai-spring/src/test/kotlin/dev/tramai/spring/TramaiAutoConfigurationTest.kt | Adds tests ensuring Spring context destruction closes the shared runtime and invalidates proxies. |
| tramai-spring/src/main/kotlin/dev/tramai/spring/TramaiAutoConfiguration.kt | Configures the Tramai bean with destroyMethod = "close". |
| tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiTest.kt | Adds tests for shared engine ownership and close propagation in sovereign mode. |
| tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignTramai.kt | Makes SovereignTramai AutoCloseable and delegates close() to standalone Tramai. |
| tramai-sovereign/api/tramai-sovereign.api | Public API update reflecting AutoCloseable + close(). |
| tramai-engine/src/test/kotlin/dev/tramai/engine/TramaiEngineTest.kt | Adds tests for post-close proxy failure, in-flight cancellation on close, and self-close non-deadlock. |
| tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt | Implements closed-state gating, suspend-invocation job tracking, and close-time cancellation/join behavior. |
| docs/modules/tramai-engine.md | Updates engine API reference text for close(). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…n runtime cache, race coverage agy round-1 review fixes (PR #226): - Suspend invokeSuspend now resumes the caller continuation exactly once even when close() cancels the tracked job BEFORE the dispatcher starts it: the block records its outcome before resuming, and invokeOnCompletion resumes with a cancellation when the block never ran — otherwise the caller's suspension would freeze forever. - close() cancels tracked caller-parented invocation jobs but never joins them: their completion is dispatched on the CALLER's dispatcher, which may be blocked waiting on this very close() (joining would deadlock). The engine scope job is still cancelled-and-joined. - SovereignTramai.runtime() caches the wrapper around the delegate's single owned runtime (repeated calls return the same instance; identity test). - New tests: close racing a fast suspend invocation never leaves work against a closed engine (100 iterations; provider-start vs close-complete ordering asserted); multiple Spring AI-service beans share one runtime and all fail after context close; sovereign runtime identity.
…gelog agy round-1 P2-3 + P3-3 (PR #226): - Blocking proxy invocations re-check the closed flag after the caller-owned runBlocking completes, so a call that raced close() surfaces the fixed 'Tramai runtime is closed' IllegalStateException instead of delivering a result computed against an already-closed engine. Test added. - CHANGELOG entry for PR #226 including the AutoCloseable supertype note (source-compatible; affects compiled negative instanceof checks).
…ming flows Independent review findings (PR #226): - P1: resumeApproval and registerService ran provider work deterministically after close() — the closed guard existed only on create(), the proxy invoke seam, and the suspend launch. Both entry points now fail fast with the fixed 'Tramai runtime is closed' IllegalStateException. - P2-1: the suspend invocation block could deliver a success computed against a closed engine (caller-parented job, not joined by close). The launched block now re-checks the closed flag after execute() and converts a success into the fixed lifecycle error, mirroring the blocking path. - P2-2: streaming flows escaped close() entirely — a flow obtained before close() and collected after ran the full provider pipeline. The flow body now fails fast on collection against a closed engine. - Tests: registerService/resumeApproval fail fast on a closed engine; streaming flow collected after close fails before provider executes (provider untouched).
Round-3 review P2 (PR #226): mid-collection close left a live provider stream delivering chunks after close() — the flow-body start guard only covered collection-after-close, and the collector's job is not cancelled by close(), so cooperative cancellation never fired. Every emitted chunk is now gated on the engine being open (emitWhileOpen), so a cold flow being collected at close() time terminates deterministically within one chunk latency with the fixed 'Tramai runtime is closed' error. Test: mid-collection close terminates an in-flight stream (first chunk delivered, close, gate release -> second chunk never delivered).
…l engine-initiated work Addresses Giona's round-5 review (PR #226): output suppression is not work termination; close() must not return while engine-created invocations are still active. - The engine now owns an internal lifecycleJob/lifecycleScope. The caller- supplied job/scope constructor parameters are NEVER cancelled or joined (fixes the caller-supplied-job close() deadlock): close() cancels and joins lifecycleJob plus every tracked invocation. - Blocking calls run as children of lifecycleJob (runBlocking(lifecycleJob)), so close() terminates a blocking provider still executing and waits for its NonCancellable cleanup before returning. - Suspend invocations run on the engine's own dispatcher (caller's Job element retained for parent-cancellation propagation; the interceptor is stripped so close() joining cannot deadlock a single-threaded caller loop). Parent-cancellation contract tests still pass. - Streaming collections run in lifecycleScope and bridge chunks to the collector's emit through a channel; close() cancels the collection job and waits for provider cleanup. Per-chunk closed gate retained. - close() now joins tracked invocation jobs (cancellation request is not termination; NonCancellable cleanup must complete before close returns). - lifecycleScope carries a CoroutineExceptionHandler so orphaned background work failures log instead of leaking onto a shared global handler. - Tests: blocking long-suspension cancelled+joined by close; streaming collection suspended indefinitely cancelled+cleaned up; close with caller-supplied job/scope does not deadlock; external-provider test now forces engine creation (was vacuous); observer fixtures thread-safe for Default-dispatcher invocations; close-race test suspends instead of blocking Thread.join. Roadmap Epic 1.3 marked complete.
Copilot thread r3749862916: 'waits for externally initiated shutdown' was ambiguous. State exactly what close() does: cancels and joins engine-owned work (blocking, suspend, streaming) and never touches the caller-supplied job/scope constructor parameters.
Round-5 rework landed — all P1/P2/P3 items addressed (review this at head
|
…se race, safe logging
Addresses Giona's round-6 review of the streaming lifecycle bridge.
- Channel.UNLIMITED -> Channel.RENDEZVOUS: a slow collector now blocks the
provider instead of letting it race ahead into unbounded buffering
(backpressure semantics preserved; take(1)/slow-collector behavior
unchanged). Regression: 'streaming bridge preserves backpressure when the
collector is slow' proves the provider cannot emit chunk 2..N while the
collector is blocked on chunk 1.
- Channel close now depends on JOB completion, not on the collection body
having started: collectJob.invokeOnCompletion { chunks.close(cause) }.
If close() cancels lifecycleJob after the flow's open check but before the
launched body runs, the collector terminates instead of hanging forever on
receive. Regression: 'stream start racing close never hangs the collector'
(200 iterations of the admission race, each bounded).
- Streaming failures are captured into collectFailure and surfaced to the
collector via the channel drain instead of being rethrown: an arbitrary
(possibly sensitive, externally supplied) throwable no longer reaches the
lifecycle CoroutineExceptionHandler and the normal logger. The handler now
logs fixed safe metadata (exception type name only), never the raw
throwable — consistent with Epic 1.2 safe-error-boundary work.
- Fixed stale close() comment: invocation jobs run on the engine's own
dispatcher (caller ContinuationInterceptor stripped), not the caller's.
Round-6 rework landed — P1/P2/P3 resolved at head
|
…close safe Addresses Giona's round-7 review. - The engine-thread marker now lives on lifecycleScope itself (engineThreadMarker.asContextElement(true) in the scope context), so EVERY engine-owned child — including the streaming collection job — carries the self-close protection automatically. Previously only blocking and suspend paths had it explicitly, so engine.close() called from inside a streaming provider/interceptor/observer would self-join forever (lifecycleJob.cancel -> join of a job blocked inside close()). - Regression: 'self close from streaming owned coroutine does not deadlock' (provider flow calls engine.close() mid-collection; withTimeout(2s) proves termination). - Roadmap: Epic 1.3 gains the leak-test evidence matrix (task 6): engine jobs, worker jobs, subprocesses, HTTP response streams, shutdown hooks each mapped to their concrete tests.
Round-7 rework landed — P1/P2/P3 addressed at head
|
| Requirement | Proof |
|---|---|
| Engine jobs | #226 lifecycle tests (blocking/suspend/streaming join, self-close both variants, close-race, caller-job) |
| Worker jobs | tramai-orchestration TramaiWorkerTest shutdown/cancellation |
| Subprocesses | SubprocessCancellationContractTest (#216/#221) |
| HTTP response streams | #226 streaming lifecycle tests + springboot example E2E |
| Shutdown hooks | Spring destroyMethod + context-shutdown tests + close idempotency |
P3 — timing-based tests
The new lifecycle regressions use withTimeout bounds and CompletableDeferred gates, not scheduler-time assumptions; the one delay(300) (backpressure test) is a deliberate negative-observation window for a broken UNLIMITED bridge, not a correctness gate.
Verification: full ./gradlew test verifyPr --rerun-tasks green (3m, 212 tasks), apiCheck additive, engine suite green including the new regression. Requesting re-review at the new head.
…t stream evidence Addresses Giona's round-8 review (P2/P3; no P1 remains). - New regression 'close deregisters the JVM shutdown hook and retains no reference' (TramaiWorkerTest): proves start() registers a hook and close() -> shutdown() removes it (private field reflection: non-null -> null). - Roadmap HTTP-stream evidence row now cites the provider-level InputStream cleanup tests in OpenAiProviderTest (close after DONE, malformed chunk, collector stop after first token, mid-stream I/O failure) instead of only the engine Flow bridge tests. - Timing determinism: the suspended-stream cleanup test's delay(200) is now a firstChunkDelivered CompletableDeferred gate; the backpressure test's delay(300) is replaced with a structural proof (provider ATTEMPTS chunk 2 then stalls at the rendezvous send: attempted==2, delivered==1).
Commit-location clarification + round-8 cleanupHead reconciliation: the round-7 commit is Round-8 items, all addressed at the new head: P2 — shutdown-hook leak proofNew regression in tramai-orchestration P3 — HTTP-stream evidence rowRoadmap now cites the actual provider-level InputStream cleanup tests in tramai-openai P3 — timing synchronization
Verification: engine + orchestration suites green (incl. new tests), full |
…just reference clearing Addresses Giona's round-8 follow-up (P2): the previous regression proved close() nulls the worker's shutdownHook field but NOT that the JVM registry was actually deregistered — a mutation removing Runtime.removeShutdownHook would still pass. Strengthened: after close(), Runtime.removeShutdownHook(hookAfterStart) must return FALSE (hook already deregistered). Verified mutation-sensitive: with the production deregistration commented out, the test FAILS; restored, it passes. close() moved into try/finally so an assertion failure can never leave a hook registered in the test JVM. Roadmap shutdown-hook row now cites this test (JVM-level deregistration + no retained Thread reference) instead of only the indirect Spring/standalone evidence.
Round-8 follow-up landed — shutdown-hook deregistration now mutation-proven (head
|
…— review round 1 P2: ExecutionComponents no longer carries the caller-supplied job/scope compatibility parameters; public ctor descriptors preserved. Engine work parents exclusively to the internally owned lifecycleJob/lifecycleScope (PR #226); the compat scope was verified dead on every launch path. Also: group KDocs reworded (caller vs engine ownership), ROADMAP Epic 2.1 marked complete, CHANGELOG + PR wording tightened.
Summary
Implements Epic 1.3 — Runtime Lifecycle Ownership (
docs/ROADMAP-0.6.0.md).Invariant: every runtime-created engine has exactly one reachable lifecycle owner, and closing that owner
deterministically prevents further work and terminates TramAI-owned work.
Critical defect fixed:
Tramai.create()created a fresh unreachableTramaiEngineon every call;Tramai.runtime()created an independent second engine; Spring@AiServicebeans could therefore produceseveral hidden engines with no lifecycle owner.
What changed
Tramai(standalone): nowAutoCloseable, owns ONE lazily-createdTramaiRuntime(→ one engine) sharedby all
create()/runtime()calls. Lifecycle state lives in class-body fields (lifecycleLock,ownedRuntime,closed) so the published JVM constructor descriptor is unchanged.close()is synchronizedand idempotent. After close,
create()/runtime()fail fast with a fixedIllegalStateException("Tramai runtime is closed").TramaiEngine:close()cancels once and awaits engine-hierarchy termination (self-close safe via athread marker). Proxies check a closed flag at the invocation seam before provider execution. Suspend
bridges launch as children of the caller's job (preserving parent-cancellation propagation) while the engine
tracks launched invocation jobs and cancels them on close. The caller continuation is resumed exactly
once even when close() cancels a job before the dispatcher starts it.
SovereignTramai:create()/runtime()route through the delegate's owned runtime (no hidden engines);runtime()returns a cached wrapper;SovereignTramaiisAutoCloseable, closing the delegate.@Bean(destroyMethod = "close")— context destruction closes the shared runtime; all@AiServicefactory beans share the one owned engine.
providers, stores, clients, executors, observers remain caller-owned unless their API transfers ownership.
Verification
./gradlew test --rerun-tasksgreen (all modules).verifyCancellationSafetyPASSED (no new findings).verifyPr -PchangeClass=public-apiPASSED (change policy + maintainability baseline).apiDump/apiCheckgreen — additive only:Tramai/SovereignTramaigainAutoCloseable/close();all existing constructor descriptors byte-identical.
Key tests
create()calls share one runtime lifecycle;runtime()returns the same runtimecreate()creates only one engine (8 threads × 50)create()racing withclose()cannot resurrect the runtime (50 iterations)close()harmless; close-before-first-use rejectsprovider-start timestamp must precede close-completion on any success)
runtime()returns the same wrapper; close propagatesFix rounds (review findings addressed)
SovereignTramaiRuntime+ identity test; P1-1 regression: exactly-once resume when a tracked job is cancelled pre-start (continuation freeze); race stress test with timestamp ordering; Spring multi-bean shared-runtime testrunBlockingclosed re-check + test); P3-3: CHANGELOG entry incl. AutoCloseable supertype noteresumeApproval/registerServiceunguarded → fail fast on closed engine (+test); P2-1: suspend block re-checks closed after execute, converts in-flight success to fixed lifecycle error; P2-2: streaming flow body fails fast on collection against closed engine (+test, provider untouched)emitWhileOpen), deterministic termination within one chunk latency (+test: second chunk never delivered after close). P3s accepted: in-flightresumeApprovaldelivers its committed result (failing post-hoc would orphan the consumed continuation); sovereign lazy wrapper returns inert-but-throwing wrapper post-closelifecycleJob/lifecycleScope; caller-suppliedjob/scopenever cancelled/joined (caller-supplied-job close deadlock fixed); blocking calls run as lifecycleJob children (close terminates + joins, waits for NonCancellable cleanup); suspend invocations run on the engine's own dispatcher with caller Job retained (parent cancellation preserved; interceptor stripped so close-join can't deadlock a single-threaded caller); streaming collections run in lifecycleScope via a channel bridge (close cancels the collection job and waits for provider cleanup);close()cancels AND joins tracked invocation jobs (cancellation ≠ termination); lifecycleScope has a CoroutineExceptionHandler so orphaned failures log instead of leaking. P2-1 vacuous external-provider test fixed (forces engine creation); Epic 1.3 marked complete in roadmap. Regressions: blocking long-suspension cancelled+joined; streaming suspended-indefinitely cancelled+cleaned-up; caller-supplied-job/scope close no deadlockScope notes
.hermes/plans/*.mdare working notes, not committed.